package com.tukez.tmdb.http
import java.util.concurrent.TimeUnit
import java.util.concurrent.Delayed
import org.joda.time.Duration
import org.apache.http.message.BasicNameValuePair
import java.util.concurrent.Semaphore
import java.util.concurrent.DelayQueue
import org.apache.http.client.fluent.Response
import org.scala_tools.time.Imports._
import scala.util.matching.Regex
import com.tukez.tmdb.TMDbRetryException
import com.tukez.tmdb.TMDbHttpException

trait RequestLimits extends IHttpClient {

  trait Settings {
    /** How many requests are allowed in `requestWindow`.
     *  At any time, there can be maximum of `maxRequests` requests in a time window specified by `requestWindow`
     *  Default value is 10.
     */
    val maxRequests: Int = 10
    /** How long is the time window on request limiting.
     *  At any time, there can be maximum of `maxRequests` requests in a time window specified by `requestWindow`
     *  Default value is 10 seconds.
     */
    val requestWindow: Duration = 10.seconds
    /** How many simultaneous connections are allowed.
     *  Default value is 20.
     */
    val maxConnections: Int = 20
  }

  /** How many requests are allowed in `requestWindow`.
   *  At any time, there can be maximum of `maxRequests` requests in a time window specified by `requestWindow`
   *  Default value is 10.
   */
  val maxRequests: Int = 10
  /** How long is the time window on request limiting.
   *  At any time, there can be maximum of `maxRequests` requests in a time window specified by `requestWindow`
   *  Default value is 10 seconds.
   */
  val requestWindow: Duration = 10.seconds
  /** How many simultaneous connections are allowed.
   *  Default value is 20.
   */
  val maxConnections: Int = 20

  /** How many times a request is retried if a response with status code defined in `httpCodesToRetry` is received.
   *  Retrying follows the same request limit rules as first time requests.
   *  Default value is 5.
   */
  val retries: Int = 5
  /** HTTP status codes to retry.
   *  If a response is received with one of these status codes, the request is retried.
   *  Default value is Set(503)
   */
  val httpCodesToRetry: Set[Int] = Set(503)

  /** Mapping from URL to Settings.
   *  On a request, URL is matched with mapping's Regex. If it matches, the mapping's Settings are used with the request.
   *
   *  The Settings are totally isolated from each other, meaning that if you have a mapping, which specifies
   *  maxConnections = 5 and also default maxConnections = 5, you can have max 10 simultaneous connections to the
   *  TMDb API if half of the connections use the mapped settings.
   *
   *  The order of mappings is important, as the URL is matched in the order specified.
   *  If the URL does not match any mapping, default settings specified outside of mappings are used.
   *
   *  Default value is a mapping, which matches with /2.1/Movie.search and specifies settings as:
   *  maxRequests = 30
   *  requestWindow = 10 seconds
   *  maxConnections = 20
   */
  val mappings: List[(Regex, Settings)] = List(""".*/2.1/Movie.search/.*""".r -> new Settings {
    override val maxRequests = 30
    override val requestWindow = 10.seconds.toDuration
    override val maxConnections = 20
  })

  private lazy val defaultSettings = new Settings {
    override val maxRequests = RequestLimits.this.maxRequests
    override val requestWindow = RequestLimits.this.requestWindow
    override val maxConnections = RequestLimits.this.maxConnections
  }

  private case class TimedPermitToken(delayInMillis: Long) extends Delayed {
    private val startTime = System.currentTimeMillis
    def getDelay(unit: TimeUnit) = unit.convert(delayInMillis - (System.currentTimeMillis - startTime), TimeUnit.MILLISECONDS)
    def compareTo(o: Delayed) = (getDelay(TimeUnit.MILLISECONDS) - o.getDelay(TimeUnit.MILLISECONDS)).toInt
  }

  private lazy val connectionPermit = new Semaphore(maxConnections)
  private lazy val permitTokens = initPermitTokens(maxRequests)

  private lazy val mappedPermitsAndTokens: Map[Settings, (Semaphore, DelayQueue[Delayed])] =
    (mappings map { pair: (Regex, Settings) =>
      (pair._2 -> (new Semaphore(pair._2.maxConnections), initPermitTokens(pair._2.maxRequests)))
    } toMap) + (defaultSettings -> (new Semaphore(maxConnections), initPermitTokens(maxRequests)))

  private def initPermitTokens(maxRequests: Int) = {
    val tokens = new DelayQueue[Delayed]
    1 to maxRequests foreach { i => tokens.add(new TimedPermitToken(0)) }
    tokens
  }

  abstract override def get(url: String): HttpResponse =
    withSettingsFor(url) { settings =>
      retrying {
        withPermit(settings) {
          super.get(url)
        }
      }
    }

  abstract override def post(url: String, params: Map[String, String]): HttpResponse =
    withSettingsFor(url) { settings =>
      retrying {
        withPermit(settings) {
          super.post(url, params)
        }
      }
    }

  private def withSettingsFor[A](url: String)(f: Settings => A): A = {
    val settings = mappings find { _._1.pattern.matcher(url).matches() } map { pair => pair._2 } getOrElse { defaultSettings }
    f(settings)
  }

  private def retrying(f: => HttpResponse): HttpResponse = {

    def retryIfRetriesLeft(retriesLeft: Int, statusCode: Int) = {
      if (retriesLeft > 0) retry(retriesLeft - 1)
      else throw new TMDbRetryException(retries max 0, statusCode)
    }

    def retry(retriesLeft: Int): HttpResponse = {
      try {
        val response = f

        if (httpCodesToRetry.contains(response.statusCode)) {
          retryIfRetriesLeft(retriesLeft, response.statusCode)
        }

        response

      } catch {
        case e: TMDbHttpException => {
          if (httpCodesToRetry.contains(e.statusCode)) {
            retryIfRetriesLeft(retriesLeft, e.statusCode)
          } else {
            throw e
          }
        }
      }
    }
    
    retry(retries max 0)
  }

  private def withPermit[A](settings: Settings)(f: => A): A = {
    val (connPermit, tokens) = mappedPermitsAndTokens.get(settings).get

    try {
      connPermit.acquire()
      timeThrottling(settings, tokens)
      f
    } finally {
      connPermit.release()
    }
  }

  private def timeThrottling(settings: Settings, tokens: DelayQueue[Delayed]) {
    tokens.take()
    tokens.put(TimedPermitToken(settings.requestWindow.getMillis))
  }
}